--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 824d84e3aca83dea2c4ae7db37ed265790454281
Parents : 1944397
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-03-06T19:42:35-06:00
Implement PageNodeManager for managing page nodes
- Updated the version in `__init__.py` and `version.py` to 4.3.0.
- Introduced `PageNodeManager` to handle the lifecycle of page nodes, including creation, deletion, and management of their state.
- Added API endpoints for listing, creating, retrieving, deleting, starting, stopping, and announcing page nodes.
- Improved the frontend with a new `PageNodesPage` component for managing page nodes and integrated publishing functionality in the `MicronEditorPage`.
Changes
18 files changed, 2103 insertions(+), 31 deletions(-)
Diff
diff --git a/meshchatx/__init__.py b/meshchatx/__init__.py
index 6b8fb581..4bac00b9 100644
--- a/meshchatx/__init__.py
+++ b/meshchatx/__init__.py
@@ -1,3 +1,3 @@
"""Reticulum MeshChatX - A mesh network communications app."""
-__version__ = "4.2.1"
+__version__ = "4.3.0"
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 8b871ed0..447ab33f 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -66,6 +66,7 @@ from meshchatx.src.backend.lxmf_utils import (
convert_lxmf_message_to_dict,
)
from meshchatx.src.backend.map_manager import TRANSPARENT_TILE
+from meshchatx.src.backend.page_node_manager import PageNodeManager
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
from meshchatx.src.backend.meshchat_utils import (
convert_db_favourite_to_dict,
@@ -278,6 +279,7 @@ class ReticulumMeshChat:
self.download_id_counter = 0
self.identity_manager = IdentityManager(self.storage_dir, identity_file_path)
+ self.page_node_manager = PageNodeManager(self.storage_dir)
# Multi-identity support
self.contexts: dict[str, IdentityContext] = {}
@@ -730,6 +732,8 @@ class ReticulumMeshChat:
if not hasattr(self, "reticulum"):
self._ensure_reticulum_config()
self.reticulum = RNS.Reticulum(self.reticulum_config_dir)
+ self.page_node_manager.load_nodes()
+ self.page_node_manager.start_all()
# Create new context
context = IdentityContext(identity, self)
@@ -741,6 +745,10 @@ class ReticulumMeshChat:
context.config,
)
+ for node in self.page_node_manager.nodes.values():
+ if node.running and node.destination:
+ self._register_local_page_node_announce(node)
+
# Link database to memory log handler
memory_log_handler.set_database(context.database)
@@ -2319,6 +2327,10 @@ class ReticulumMeshChat:
# web server has shutdown, likely ctrl+c, but if we don't do the following, the script never exits
async def shutdown(self, app):
+ # stop page nodes before tearing down reticulum
+ if hasattr(self, "page_node_manager"):
+ self.page_node_manager.teardown()
+
# force close websocket clients
for websocket_client in self.websocket_clients:
try:
@@ -6917,6 +6929,168 @@ class ReticulumMeshChat:
status=500,
)
+ # --- Page Node API ---
+
+ @routes.get("/api/v1/page-nodes")
+ async def page_nodes_list(request):
+ return web.json_response(self.page_node_manager.list_nodes())
+
+ @routes.post("/api/v1/page-nodes")
+ async def page_nodes_create(request):
+ data = await request.json()
+ name = data.get("name", "").strip()
+ if not name:
+ return web.json_response({"message": "Name is required"}, status=400)
+ node = self.page_node_manager.create_node(name)
+ return web.json_response(node.get_status())
+
+ @routes.get("/api/v1/page-nodes/{node_id}")
+ async def page_nodes_get(request):
+ node_id = request.match_info["node_id"]
+ node = self.page_node_manager.get_node(node_id)
+ if not node:
+ return web.json_response({"message": "Node not found"}, status=404)
+ return web.json_response(node.get_status())
+
+ @routes.delete("/api/v1/page-nodes/{node_id}")
+ async def page_nodes_delete(request):
+ node_id = request.match_info["node_id"]
+ if self.page_node_manager.delete_node(node_id):
+ return web.json_response({"message": "Node deleted"})
+ return web.json_response({"message": "Node not found"}, status=404)
+
+ @routes.post("/api/v1/page-nodes/{node_id}/start")
+ async def page_nodes_start(request):
+ node_id = request.match_info["node_id"]
+ try:
+ dest_hash = self.page_node_manager.start_node(node_id)
+ node = self.page_node_manager.get_node(node_id)
+ if node and node.running:
+ self._register_local_page_node_announce(node)
+ return web.json_response(
+ {"destination_hash": dest_hash, "message": "Node started"}
+ )
+ except KeyError:
+ return web.json_response({"message": "Node not found"}, status=404)
+
+ @routes.post("/api/v1/page-nodes/{node_id}/stop")
+ async def page_nodes_stop(request):
+ node_id = request.match_info["node_id"]
+ try:
+ self.page_node_manager.stop_node(node_id)
+ return web.json_response({"message": "Node stopped"})
+ except KeyError:
+ return web.json_response({"message": "Node not found"}, status=404)
+
+ @routes.post("/api/v1/page-nodes/{node_id}/announce")
+ async def page_nodes_announce(request):
+ node_id = request.match_info["node_id"]
+ try:
+ node = self.page_node_manager.get_node(node_id)
+ if node is None or not node.running:
+ return web.json_response(
+ {"message": "Node not running"}, status=400
+ )
+ node.announce()
+ self._register_local_page_node_announce(node)
+ return web.json_response({"message": "Announced"})
+ except KeyError:
+ return web.json_response({"message": "Node not found"}, status=404)
+
+ @routes.put("/api/v1/page-nodes/{node_id}/rename")
+ async def page_nodes_rename(request):
+ node_id = request.match_info["node_id"]
+ data = await request.json()
+ new_name = data.get("name", "").strip()
+ if not new_name:
+ return web.json_response({"message": "Name is required"}, status=400)
+ try:
+ self.page_node_manager.rename_node(node_id, new_name)
+ return web.json_response({"message": "Renamed"})
+ except KeyError:
+ return web.json_response({"message": "Node not found"}, status=404)
+
+ @routes.get("/api/v1/page-nodes/{node_id}/pages")
+ async def page_nodes_list_pages(request):
+ node_id = request.match_info["node_id"]
+ node = self.page_node_manager.get_node(node_id)
+ if not node:
+ return web.json_response({"message": "Node not found"}, status=404)
+ return web.json_response({"pages": node.list_pages()})
+
+ @routes.post("/api/v1/page-nodes/{node_id}/pages")
+ async def page_nodes_add_page(request):
+ node_id = request.match_info["node_id"]
+ node = self.page_node_manager.get_node(node_id)
+ if not node:
+ return web.json_response({"message": "Node not found"}, status=404)
+ data = await request.json()
+ name = data.get("name", "")
+ content = data.get("content", "")
+ if not name:
+ return web.json_response(
+ {"message": "Page name is required"}, status=400
+ )
+ saved_name = node.add_page(name, content)
+ return web.json_response({"name": saved_name, "message": "Page saved"})
+
+ @routes.get("/api/v1/page-nodes/{node_id}/pages/{page_name}")
+ async def page_nodes_get_page(request):
+ node_id = request.match_info["node_id"]
+ page_name = request.match_info["page_name"]
+ node = self.page_node_manager.get_node(node_id)
+ if not node:
+ return web.json_response({"message": "Node not found"}, status=404)
+ content = node.get_page_content(page_name)
+ if content is None:
+ return web.json_response({"message": "Page not found"}, status=404)
+ return web.json_response({"name": page_name, "content": content})
+
+ @routes.delete("/api/v1/page-nodes/{node_id}/pages/{page_name}")
+ async def page_nodes_delete_page(request):
+ node_id = request.match_info["node_id"]
+ page_name = request.match_info["page_name"]
+ node = self.page_node_manager.get_node(node_id)
+ if not node:
+ return web.json_response({"message": "Node not found"}, status=404)
+ if node.remove_page(page_name):
+ return web.json_response({"message": "Page deleted"})
+ return web.json_response({"message": "Page not found"}, status=404)
+
+ @routes.get("/api/v1/page-nodes/{node_id}/files")
+ async def page_nodes_list_files(request):
+ node_id = request.match_info["node_id"]
+ node = self.page_node_manager.get_node(node_id)
+ if not node:
+ return web.json_response({"message": "Node not found"}, status=404)
+ return web.json_response({"files": node.list_files()})
+
+ @routes.post("/api/v1/page-nodes/{node_id}/files")
+ async def page_nodes_upload_file(request):
+ node_id = request.match_info["node_id"]
+ node = self.page_node_manager.get_node(node_id)
+ if not node:
+ return web.json_response({"message": "Node not found"}, status=404)
+ reader = await request.multipart()
+ field = await reader.next()
+ if field is None:
+ return web.json_response({"message": "No file uploaded"}, status=400)
+ filename = field.filename or "upload"
+ file_data = await field.read()
+ saved_name = node.add_file(filename, file_data)
+ return web.json_response({"name": saved_name, "message": "File uploaded"})
+
+ @routes.delete("/api/v1/page-nodes/{node_id}/files/{file_name}")
+ async def page_nodes_delete_file(request):
+ node_id = request.match_info["node_id"]
+ file_name = request.match_info["file_name"]
+ node = self.page_node_manager.get_node(node_id)
+ if not node:
+ return web.json_response({"message": "Node not found"}, status=404)
+ if node.remove_file(file_name):
+ return web.json_response({"message": "File deleted"})
+ return web.json_response({"message": "File not found"}, status=404)
+
@routes.get("/api/v1/rnstatus")
async def rnstatus(request):
include_link_stats = request.query.get("include_link_stats", "false") in (
@@ -9880,6 +10054,35 @@ class ReticulumMeshChat:
# convert destination hash to bytes
destination_hash = bytes.fromhex(destination_hash)
+ local_file = self._try_serve_local_page_node_file(
+ destination_hash,
+ file_path,
+ )
+ if local_file is not None:
+ file_name, file_bytes = local_file
+ self.download_id_counter += 1
+ download_id = self.download_id_counter
+ AsyncUtils.run_async(
+ client.send_str(
+ json.dumps(
+ {
+ "type": "nomadnet.file.download",
+ "download_id": download_id,
+ "nomadnet_file_download": {
+ "status": "success",
+ "destination_hash": destination_hash.hex(),
+ "file_path": file_path,
+ "file_name": file_name,
+ "file_bytes": base64.b64encode(file_bytes).decode(
+ "utf-8",
+ ),
+ },
+ },
+ ),
+ ),
+ )
+ return
+
# generate download id
self.download_id_counter += 1
download_id = self.download_id_counter
@@ -10032,6 +10235,30 @@ class ReticulumMeshChat:
# convert destination hash to bytes
destination_hash = bytes.fromhex(destination_hash)
+ local_page = self._try_serve_local_page_node(
+ destination_hash,
+ page_path_to_download,
+ )
+ if local_page is not None:
+ self.archive_page(destination_hash.hex(), page_path, local_page)
+ AsyncUtils.run_async(
+ client.send_str(
+ json.dumps(
+ {
+ "type": "nomadnet.page.download",
+ "download_id": download_id,
+ "nomadnet_page_download": {
+ "status": "success",
+ "destination_hash": destination_hash.hex(),
+ "page_path": page_path,
+ "page_content": local_page,
+ },
+ },
+ ),
+ ),
+ )
+ return
+
# handle successful page download
def on_page_download_success(page_content):
# remove from active downloads
@@ -12171,6 +12398,74 @@ class ReticulumMeshChat:
# queue crawler task (existence check in queue_crawler_task handles duplicates)
self.queue_crawler_task(destination_hash.hex(), "/page/index.mu")
+ def _try_serve_local_page_node(self, destination_hash, page_path):
+ """If destination_hash belongs to one of our page nodes, serve the page
+ directly from disk. Returns the page content string or None."""
+ for node in self.page_node_manager.nodes.values():
+ if not node.running or not node.destination:
+ continue
+ if node.destination.hash == destination_hash:
+ page_name = page_path.lstrip("/")
+ if page_name.startswith("page/"):
+ page_name = page_name[5:]
+ content = node.get_page_content(page_name)
+ if content is not None:
+ node._stats["pages_served"] += 1
+ return content
+ return None
+
+ def _try_serve_local_page_node_file(self, destination_hash, file_path):
+ """If destination_hash belongs to one of our page nodes, serve the file
+ directly from disk. Returns (file_name, file_bytes) or None."""
+ for node in self.page_node_manager.nodes.values():
+ if not node.running or not node.destination:
+ continue
+ if node.destination.hash == destination_hash:
+ file_name = file_path.lstrip("/")
+ if file_name.startswith("file/"):
+ file_name = file_name[5:]
+ file_name = os.path.basename(file_name)
+ full_path = os.path.join(node.files_dir, file_name)
+ if os.path.isfile(full_path):
+ with open(full_path, "rb") as f:
+ file_bytes = f.read()
+ node._stats["files_served"] += 1
+ return (file_name, file_bytes)
+ return None
+ return None
+
+ def _register_local_page_node_announce(self, node):
+ """Inject a local page node's announce into the database so it appears
+ in the NomadNet announces list without relying on RNS loopback."""
+ ctx = self.current_context
+ if not ctx or not ctx.running or not ctx.announce_manager or not ctx.database:
+ return
+ if not node.destination or not node.identity:
+ return
+ destination_hash = node.destination.hash
+ aspect = "nomadnetwork.node"
+ app_data = node.name.encode("utf-8")
+ ctx.announce_manager.upsert_announce(
+ self.reticulum,
+ node.identity,
+ destination_hash,
+ aspect,
+ app_data,
+ None,
+ )
+ announce = ctx.database.announces.get_announce_by_hash(destination_hash.hex())
+ if announce:
+ AsyncUtils.run_async(
+ self.websocket_broadcast(
+ json.dumps(
+ {
+ "type": "announce",
+ "announce": self.convert_db_announce_to_dict(announce),
+ },
+ ),
+ ),
+ )
+
# queues a crawler task for the provided destination and path
def queue_crawler_task(self, destination_hash: str, page_path: str, context=None):
ctx = context or self.current_context
diff --git a/meshchatx/src/backend/page_node.py b/meshchatx/src/backend/page_node.py
new file mode 100644
index 00000000..6c0dcb58
--- /dev/null
+++ b/meshchatx/src/backend/page_node.py
@@ -0,0 +1,344 @@
+"""
+PageNode: Serves Micron pages and files over RNS.
+
+Each PageNode owns an RNS Destination (SINGLE, IN) with the aspect
+nomadnetwork.node and registers per-page request handlers at
+paths like /page/index.mu. This makes page nodes compatible with
+the standard NomadNet page browsing protocol (which is just RNS
+request/response with specific path conventions).
+
+Clients link to the destination and call link.request("/page/name.mu")
+to fetch a page, or /file/name for files.
+"""
+
+import json
+import os
+
+import RNS
+
+
+APP_NAME = "nomadnetwork"
+ASPECT = "node"
+DEFAULT_INDEX = "index.mu"
+
+
+class PageNode:
+ """A single page-serving node on the Reticulum mesh."""
+
+ def __init__(self, node_id, name, base_dir, identity=None, identity_path=None):
+ self.node_id = node_id
+ self.name = name
+ self.base_dir = base_dir
+ self.pages_dir = os.path.join(base_dir, "pages")
+ self.files_dir = os.path.join(base_dir, "files")
+
+ self.identity = identity
+ self.identity_path = identity_path or os.path.join(base_dir, "identity")
+ self.destination = None
+ self.active_links = []
+ self.running = False
+ self._registered_page_paths = set()
+ self._registered_file_paths = set()
+ self._stats = {"pages_served": 0, "files_served": 0, "links_established": 0}
+
+ def setup(self):
+ """Create directories, load or create identity, set up RNS destination."""
+ os.makedirs(self.pages_dir, exist_ok=True)
+ os.makedirs(self.files_dir, exist_ok=True)
+
+ if self.identity is None:
+ if os.path.isfile(self.identity_path):
+ self.identity = RNS.Identity.from_file(self.identity_path)
+ else:
+ self.identity = RNS.Identity()
+ self.identity.to_file(self.identity_path)
+
+ self.destination = RNS.Destination(
+ self.identity,
+ RNS.Destination.IN,
+ RNS.Destination.SINGLE,
+ APP_NAME,
+ ASPECT,
+ )
+
+ self.destination.set_link_established_callback(self._link_established)
+
+ self._register_existing_pages()
+ self._register_existing_files()
+ self._ensure_local_path()
+
+ self.running = True
+ return self.destination.hash.hex()
+
+ def announce(self):
+ """Broadcast this node's presence on the mesh."""
+ if self.destination and self.running:
+ app_data = self.name.encode("utf-8")
+ self.destination.announce(app_data=app_data)
+ self._ensure_local_path()
+
+ def teardown(self):
+ """Deregister handlers and clean up."""
+ self.running = False
+ if self.destination:
+ for rpath in list(self._registered_page_paths):
+ self.destination.deregister_request_handler(rpath)
+ for rpath in list(self._registered_file_paths):
+ self.destination.deregister_request_handler(rpath)
+ self._registered_page_paths.clear()
+ self._registered_file_paths.clear()
+
+ RNS.Transport.deregister_destination(self.destination)
+ self.destination = None
+
+ for link in list(self.active_links):
+ try:
+ link.teardown()
+ except Exception:
+ pass
+ self.active_links.clear()
+
+ def _link_established(self, link):
+ self.active_links.append(link)
+ self._stats["links_established"] += 1
+ link.set_link_closed_callback(self._link_closed)
+
+ def _link_closed(self, link):
+ if link in self.active_links:
+ self.active_links.remove(link)
+
+ def _ensure_local_path(self):
+ """
+ Register the destination's identity in RNS.Identity.known_destinations
+ so that Identity.recall() can resolve it for local link establishment.
+ """
+ if not self.destination:
+ return
+ RNS.Identity.remember(
+ packet_hash=None,
+ destination_hash=self.destination.hash,
+ public_key=self.identity.get_public_key(),
+ app_data=self.name.encode("utf-8"),
+ )
+
+ def _page_request_path(self, page_name):
+ """Build the NomadNet-style request path for a page."""
+ return f"/page/{page_name}"
+
+ def _file_request_path(self, file_name):
+ """Build the NomadNet-style request path for a file."""
+ return f"/file/{file_name}"
+
+ def _register_existing_pages(self):
+ """Scan pages directory and register a handler for each page."""
+ if not os.path.isdir(self.pages_dir):
+ return
+ for fname in os.listdir(self.pages_dir):
+ if os.path.isfile(os.path.join(self.pages_dir, fname)):
+ self._register_page_handler(fname)
+
+ def _register_existing_files(self):
+ """Scan files directory and register a handler for each file."""
+ if not os.path.isdir(self.files_dir):
+ return
+ for fname in os.listdir(self.files_dir):
+ if os.path.isfile(os.path.join(self.files_dir, fname)):
+ self._register_file_handler(fname)
+
+ def _register_page_handler(self, page_name):
+ """Register a request handler for a specific page."""
+ if not self.destination:
+ return
+ rpath = self._page_request_path(page_name)
+ if rpath in self._registered_page_paths:
+ return
+ self.destination.register_request_handler(
+ rpath,
+ response_generator=self._make_page_responder(page_name),
+ allow=RNS.Destination.ALLOW_ALL,
+ )
+ self._registered_page_paths.add(rpath)
+
+ def _deregister_page_handler(self, page_name):
+ """Deregister the request handler for a specific page."""
+ if not self.destination:
+ return
+ rpath = self._page_request_path(page_name)
+ if rpath not in self._registered_page_paths:
+ return
+ self.destination.deregister_request_handler(rpath)
+ self._registered_page_paths.discard(rpath)
+
+ def _register_file_handler(self, file_name):
+ """Register a request handler for a specific file."""
+ if not self.destination:
+ return
+ rpath = self._file_request_path(file_name)
+ if rpath in self._registered_file_paths:
+ return
+ self.destination.register_request_handler(
+ rpath,
+ response_generator=self._make_file_responder(file_name),
+ allow=RNS.Destination.ALLOW_ALL,
+ )
+ self._registered_file_paths.add(rpath)
+
+ def _deregister_file_handler(self, file_name):
+ """Deregister the request handler for a specific file."""
+ if not self.destination:
+ return
+ rpath = self._file_request_path(file_name)
+ if rpath not in self._registered_file_paths:
+ return
+ self.destination.deregister_request_handler(rpath)
+ self._registered_file_paths.discard(rpath)
+
+ def _make_page_responder(self, page_name):
+ """Return a closure that serves a specific page."""
+
+ def responder(path, data, request_id, link_id, remote_identity, requested_at):
+ safe_name = os.path.basename(page_name)
+ page_path = os.path.join(self.pages_dir, safe_name)
+ if not os.path.isfile(page_path):
+ return None
+ try:
+ with open(page_path, "rb") as f:
+ content = f.read()
+ self._stats["pages_served"] += 1
+ return content
+ except Exception:
+ return None
+
+ return responder
+
+ def _make_file_responder(self, file_name):
+ """Return a closure that serves a specific file."""
+
+ def responder(path, data, request_id, link_id, remote_identity, requested_at):
+ safe_name = os.path.basename(file_name)
+ file_path = os.path.join(self.files_dir, safe_name)
+ if not os.path.isfile(file_path):
+ return None
+ try:
+ fh = open(file_path, "rb")
+ metadata = {"name": safe_name.encode("utf-8")}
+ self._stats["files_served"] += 1
+ return [fh, metadata]
+ except Exception:
+ return None
+
+ return responder
+
+ def add_page(self, name, content):
+ """Write a Micron page and register its request handler."""
+ name = os.path.basename(name)
+ if not name.endswith(".mu"):
+ name += ".mu"
+ page_path = os.path.join(self.pages_dir, name)
+ if isinstance(content, str):
+ content = content.encode("utf-8")
+ with open(page_path, "wb") as f:
+ f.write(content)
+ if self.running:
+ self._register_page_handler(name)
+ return name
+
+ def remove_page(self, name):
+ """Remove a page and deregister its request handler."""
+ name = os.path.basename(name)
+ page_path = os.path.join(self.pages_dir, name)
+ if os.path.isfile(page_path):
+ os.remove(page_path)
+ self._deregister_page_handler(name)
+ return True
+ return False
+
+ def list_pages(self):
+ """Return a list of page names."""
+ if not os.path.isdir(self.pages_dir):
+ return []
+ return sorted(
+ f
+ for f in os.listdir(self.pages_dir)
+ if os.path.isfile(os.path.join(self.pages_dir, f))
+ )
+
+ def get_page_content(self, name):
+ """Read and return a page's content."""
+ name = os.path.basename(name)
+ page_path = os.path.join(self.pages_dir, name)
+ if not os.path.isfile(page_path):
+ return None
+ with open(page_path, "r", encoding="utf-8") as f:
+ return f.read()
+
+ def add_file(self, name, data):
+ """Write a file and register its request handler."""
+ name = os.path.basename(name)
+ file_path = os.path.join(self.files_dir, name)
+ mode = "wb" if isinstance(data, bytes) else "w"
+ with open(file_path, mode) as f:
+ f.write(data)
+ if self.running:
+ self._register_file_handler(name)
+ return name
+
+ def remove_file(self, name):
+ """Remove a file and deregister its request handler."""
+ name = os.path.basename(name)
+ file_path = os.path.join(self.files_dir, name)
+ if os.path.isfile(file_path):
+ os.remove(file_path)
+ self._deregister_file_handler(name)
+ return True
+ return False
+
+ def list_files(self):
+ """Return a list of file dicts with name and size."""
+ if not os.path.isdir(self.files_dir):
+ return []
+ result = []
+ for fname in sorted(os.listdir(self.files_dir)):
+ fpath = os.path.join(self.files_dir, fname)
+ if os.path.isfile(fpath):
+ result.append({"name": fname, "size": os.path.getsize(fpath)})
+ return result
+
+ def get_destination_hash(self):
+ """Return the hex destination hash if running."""
+ if self.destination:
+ return self.destination.hash.hex()
+ return None
+
+ def get_status(self):
+ """Return current node status dict."""
+ return {
+ "node_id": self.node_id,
+ "name": self.name,
+ "running": self.running,
+ "destination_hash": self.get_destination_hash(),
+ "identity_hash": self.identity.hash.hex() if self.identity else None,
+ "active_links": len(self.active_links),
+ "pages": self.list_pages(),
+ "files": self.list_files(),
+ "stats": dict(self._stats),
+ }
+
+ def save_config(self):
+ """Persist node configuration to disk."""
+ config = {
+ "node_id": self.node_id,
+ "name": self.name,
+ }
+ config_path = os.path.join(self.base_dir, "config.json")
+ with open(config_path, "w") as f:
+ json.dump(config, f, indent=2)
+
+ @staticmethod
+ def load_config(base_dir):
+ """Load node configuration from disk. Returns dict or None."""
+ config_path = os.path.join(base_dir, "config.json")
+ if not os.path.isfile(config_path):
+ return None
+ with open(config_path, "r") as f:
+ return json.load(f)
diff --git a/meshchatx/src/backend/page_node_manager.py b/meshchatx/src/backend/page_node_manager.py
new file mode 100644
index 00000000..665a143c
--- /dev/null
+++ b/meshchatx/src/backend/page_node_manager.py
@@ -0,0 +1,142 @@
+"""
+PageNodeManager: Manages the lifecycle of multiple PageNode instances.
+
+Handles creation, deletion, persistence, start/stop, and announce
+scheduling for page nodes. Each node gets its own subdirectory under
+``storage/page_nodes/<node_id>/``.
+"""
+
+import os
+import uuid
+
+from meshchatx.src.backend.page_node import PageNode
+
+
+class PageNodeManager:
+ """Manages multiple PageNode instances."""
+
+ def __init__(self, storage_dir):
+ self.storage_dir = os.path.join(storage_dir, "page_nodes")
+ os.makedirs(self.storage_dir, exist_ok=True)
+ self.nodes: dict[str, PageNode] = {}
+
+ def load_nodes(self):
+ """Discover and load all persisted nodes from disk."""
+ if not os.path.isdir(self.storage_dir):
+ return
+
+ for entry in os.listdir(self.storage_dir):
+ node_dir = os.path.join(self.storage_dir, entry)
+ if not os.path.isdir(node_dir):
+ continue
+ config = PageNode.load_config(node_dir)
+ if config is None:
+ continue
+ node_id = config["node_id"]
+ if node_id in self.nodes:
+ continue
+ node = PageNode(
+ node_id=node_id,
+ name=config["name"],
+ base_dir=node_dir,
+ )
+ self.nodes[node_id] = node
+
+ def create_node(self, name, node_id=None):
+ """Create a new page node, persist its config, and return it."""
+ if node_id is None:
+ node_id = str(uuid.uuid4())
+
+ node_dir = os.path.join(self.storage_dir, node_id)
+ os.makedirs(node_dir, exist_ok=True)
+
+ node = PageNode(node_id=node_id, name=name, base_dir=node_dir)
+ node.save_config()
+ self.nodes[node_id] = node
+ return node
+
+ def delete_node(self, node_id):
+ """Stop and remove a node, deleting its directory."""
+ node = self.nodes.get(node_id)
+ if node is None:
+ return False
+
+ if node.running:
+ node.teardown()
+
+ import shutil
+
+ shutil.rmtree(node.base_dir, ignore_errors=True)
+ del self.nodes[node_id]
+ return True
+
+ def start_node(self, node_id):
+ """Set up and start serving for a specific node."""
+ node = self.nodes.get(node_id)
+ if node is None:
+ raise KeyError(f"Node {node_id} not found")
+ if node.running:
+ return node.get_destination_hash()
+
+ dest_hash = node.setup()
+ return dest_hash
+
+ def stop_node(self, node_id):
+ """Stop serving for a specific node."""
+ node = self.nodes.get(node_id)
+ if node is None:
+ raise KeyError(f"Node {node_id} not found")
+ node.teardown()
+
+ def start_all(self):
+ """Start all loaded nodes."""
+ for node_id in list(self.nodes.keys()):
+ try:
+ self.start_node(node_id)
+ except Exception as e:
+ print(f"Failed to start page node {node_id}: {e}")
+
+ def stop_all(self):
+ """Stop all running nodes."""
+ for node_id in list(self.nodes.keys()):
+ try:
+ self.stop_node(node_id)
+ except Exception as e:
+ print(f"Failed to stop page node {node_id}: {e}")
+
+ def announce_node(self, node_id):
+ """Send an announce for a specific node."""
+ node = self.nodes.get(node_id)
+ if node is None:
+ raise KeyError(f"Node {node_id} not found")
+ node.announce()
+
+ def announce_all(self):
+ """Send announces for all running nodes."""
+ for node in self.nodes.values():
+ if node.running:
+ try:
+ node.announce()
+ except Exception as e:
+ print(f"Failed to announce page node {node.node_id}: {e}")
+
+ def rename_node(self, node_id, new_name):
+ """Rename a node and persist the change."""
+ node = self.nodes.get(node_id)
+ if node is None:
+ raise KeyError(f"Node {node_id} not found")
+ node.name = new_name
+ node.save_config()
+
+ def get_node(self, node_id):
+ """Return a node by id or None."""
+ return self.nodes.get(node_id)
+
+ def list_nodes(self):
+ """Return status dicts for all nodes."""
+ return [node.get_status() for node in self.nodes.values()]
+
+ def teardown(self):
+ """Stop all nodes and clear state."""
+ self.stop_all()
+ self.nodes.clear()
diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
index 58e70c4c..37494580 100644
--- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
+++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
@@ -136,7 +136,7 @@
<MaterialDesignIcon icon-name="import" class="size-4" />
Quick Import
</h3>
- <span class="text-[10px] text-gray-400">Paste raw TOML config here</span>
+ <span class="text-[10px] text-gray-400">Paste raw config here</span>
</div>
<textarea
v-model="rawConfigInput"
@@ -1158,7 +1158,7 @@ export default {
this.newInterfaceName = config.name || this.newInterfaceName;
this.newInterfaceType = config.type;
- // Map TOML keys to component data
+ // Map raw config keys to component data
if (config.target_host) this.newInterfaceTargetHost = config.target_host;
if (config.target_port) this.newInterfaceTargetPort = Number(config.target_port);
if (config.listen_ip) this.newInterfaceListenIp = config.listen_ip;
diff --git a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
index 17cee29a..8e1213ab 100644
--- a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
+++ b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
@@ -37,6 +37,49 @@
<MaterialDesignIcon icon-name="download" class="w-3.5 h-3.5" />
<span class="hidden sm:inline">{{ $t("tools.micron_editor.save") }}</span>
</button>
+ <div class="relative">
+ <button type="button" class="primary-chip !py-1 !px-3" @click="togglePublishMenu">
+ <MaterialDesignIcon icon-name="publish" class="w-3.5 h-3.5" />
+ <span class="hidden sm:inline">Publish</span>
+ </button>
+ <div
+ v-if="showPublishMenu"
+ v-click-outside="() => (showPublishMenu = false)"
+ class="absolute right-0 top-full mt-1 w-64 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-zinc-700 z-50 py-2"
+ >
+ <div
+ class="px-3 py-1.5 text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"
+ >
+ Publish to Mesh Server
+ </div>
+ <div v-if="pageNodes.length === 0" class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
+ No mesh servers available.
+ <router-link to="/mesh-server" class="text-blue-500 hover:underline"
+ >Create one</router-link
+ >
+ </div>
+ <button
+ v-for="pn in pageNodes"
+ :key="pn.node_id"
+ class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-zinc-700 flex items-center gap-2 transition-colors"
+ @click="publishToNode(pn)"
+ >
+ <div
+ class="w-2 h-2 rounded-full shrink-0"
+ :class="pn.running ? 'bg-green-500' : 'bg-gray-400'"
+ ></div>
+ <span class="truncate text-gray-900 dark:text-white">{{ pn.name }}</span>
+ </button>
+ <div class="border-t border-gray-200 dark:border-zinc-700 mt-1 pt-1">
+ <button
+ class="w-full text-left px-3 py-2 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
+ @click="publishAllToNode"
+ >
+ Publish all tabs to server...
+ </button>
+ </div>
+ </div>
+ </div>
<button v-if="isMobileView" type="button" class="primary-chip !py-1 !px-3" @click="toggleView">
<MaterialDesignIcon :icon-name="showEditor ? 'eye' : 'pencil'" class="w-3.5 h-3.5" />
{{ showEditor ? $t("tools.micron_editor.view_preview") : $t("tools.micron_editor.edit") }}
@@ -144,6 +187,8 @@ export default {
storageKey: "micron_editor_content",
editingTabIndex: -1,
editingTabName: "",
+ showPublishMenu: false,
+ pageNodes: [],
};
},
watch: {
@@ -740,6 +785,60 @@ ${b}=
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
+ async togglePublishMenu() {
+ this.showPublishMenu = !this.showPublishMenu;
+ if (this.showPublishMenu) {
+ try {
+ const response = await window.axios.get("/api/v1/page-nodes");
+ this.pageNodes = response.data;
+ } catch {
+ this.pageNodes = [];
+ }
+ }
+ },
+ async publishToNode(node) {
+ const tab = this.tabs[this.activeTabIndex];
+ const pageName = tab.name.replace(/\s+/g, "_");
+ try {
+ await window.axios.post(`/api/v1/page-nodes/${node.node_id}/pages`, {
+ name: pageName,
+ content: tab.content,
+ });
+ this.showPublishMenu = false;
+ DialogUtils.alert(`Published "${pageName}.mu" to ${node.name}`);
+ } catch (e) {
+ DialogUtils.alert(e.response?.data?.message || "Failed to publish page");
+ }
+ },
+ async publishAllToNode() {
+ if (this.pageNodes.length === 0) return;
+
+ const nodeNames = this.pageNodes.map((n) => n.name);
+ const nodeName = prompt("Enter server name to publish all tabs to:\n\nAvailable: " + nodeNames.join(", "));
+ if (!nodeName) return;
+
+ const node = this.pageNodes.find((n) => n.name === nodeName);
+ if (!node) {
+ DialogUtils.alert("Server not found: " + nodeName);
+ return;
+ }
+
+ let published = 0;
+ for (const tab of this.tabs) {
+ const pageName = tab.name.replace(/\s+/g, "_");
+ try {
+ await window.axios.post(`/api/v1/page-nodes/${node.node_id}/pages`, {
+ name: pageName,
+ content: tab.content,
+ });
+ published++;
+ } catch {
+ console.error(`Failed to publish tab: ${tab.name}`);
+ }
+ }
+ this.showPublishMenu = false;
+ DialogUtils.alert(`Published ${published}/${this.tabs.length} tabs to ${node.name}`);
+ },
},
};
</script>
diff --git a/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue b/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue
new file mode 100644
index 00000000..2ab65b3d
--- /dev/null
+++ b/meshchatx/src/frontend/components/page-nodes/PageNodesPage.vue
@@ -0,0 +1,576 @@
+<template>
+ <div
+ class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
+ >
+ <div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
+ <div class="space-y-4 w-full max-w-5xl mx-auto">
+ <div class="glass-card space-y-5">
+ <div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
+ <div class="space-y-2">
+ <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+ Reticulum Content Serving
+ </div>
+ <div class="text-2xl font-semibold text-gray-900 dark:text-white">Mesh Server</div>
+ <div class="text-sm text-gray-600 dark:text-gray-300">
+ Serve Micron pages and files directly over the Reticulum mesh. Each server gets its own
+ identity and destination address.
+ </div>
+ </div>
+ <button
+ type="button"
+ class="primary-chip px-4 py-2 text-sm shrink-0"
+ @click="showCreateDialog = true"
+ >
+ <MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
+ Create Node
+ </button>
+ </div>
+ </div>
+
+ <div v-if="loading" class="glass-card text-center py-12">
+ <div class="text-gray-500 dark:text-gray-400">Loading nodes...</div>
+ </div>
+
+ <div v-else-if="nodes.length === 0" class="glass-card text-center py-12">
+ <MaterialDesignIcon icon-name="server-network" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
+ <div class="text-gray-600 dark:text-gray-400 mb-2">No mesh servers yet</div>
+ <div class="text-sm text-gray-500 dark:text-gray-500">
+ Create a server to start serving Micron pages over the mesh.
+ </div>
+ </div>
+
+ <div v-else class="space-y-3">
+ <div
+ v-for="node in nodes"
+ :key="node.node_id"
+ class="glass-card space-y-4 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 transition"
+ @click="selectNode(node)"
+ >
+ <div class="flex items-center justify-between">
+ <div class="flex items-center gap-3">
+ <div
+ class="w-3 h-3 rounded-full"
+ :class="node.running ? 'bg-green-500' : 'bg-gray-400'"
+ ></div>
+ <div>
+ <div class="font-semibold text-gray-900 dark:text-white">{{ node.name }}</div>
+ <div
+ v-if="node.destination_hash"
+ class="text-xs font-mono text-gray-500 dark:text-gray-400"
+ >
+ {{ node.destination_hash }}
+ </div>
+ </div>
+ </div>
+ <div class="flex items-center gap-2">
+ <span class="text-xs text-gray-500 dark:text-gray-400">
+ {{ node.pages.length }} pages, {{ node.files.length }} files
+ </span>
+ <button
+ v-if="!node.running"
+ class="primary-chip !py-1 !px-3 !text-xs"
+ @click.stop="startNode(node.node_id)"
+ >
+ Start
+ </button>
+ <button
+ v-else
+ class="secondary-chip !py-1 !px-3 !text-xs !text-red-500 hover:!bg-red-50 dark:hover:!bg-red-900/20"
+ @click.stop="stopNode(node.node_id)"
+ >
+ Stop
+ </button>
+ <button
+ v-if="node.running"
+ class="secondary-chip !py-1 !px-3 !text-xs"
+ @click.stop="announceNode(node.node_id)"
+ >
+ Announce
+ </button>
+ <button
+ v-if="node.running && node.destination_hash"
+ class="secondary-chip !py-1 !px-3 !text-xs"
+ @click.stop="viewNode(node)"
+ >
+ <MaterialDesignIcon icon-name="eye" class="w-3.5 h-3.5" />
+ View
+ </button>
+ <button
+ class="secondary-chip !py-1 !px-3 !text-xs !text-red-500 hover:!bg-red-50 dark:hover:!bg-red-900/20"
+ @click.stop="deleteNode(node.node_id)"
+ >
+ <MaterialDesignIcon icon-name="delete" class="w-3.5 h-3.5" />
+ </button>
+ </div>
+ </div>
+
+ <div v-if="node.stats" class="flex gap-4 text-xs text-gray-500 dark:text-gray-400">
+ <span>{{ node.stats.pages_served }} pages served</span>
+ <span>{{ node.stats.files_served }} files served</span>
+ <span>{{ node.stats.links_established }} links</span>
+ </div>
+ </div>
+ </div>
+
+ <!-- Selected Node Detail -->
+ <div v-if="selectedNode" class="glass-card space-y-5">
+ <div class="flex items-center justify-between">
+ <div class="text-lg font-semibold text-gray-900 dark:text-white">
+ {{ selectedNode.name }}
+ </div>
+ <div class="flex items-center gap-2">
+ <button class="secondary-chip !py-1 !px-3 !text-xs" @click="showRenameDialog = true">
+ Rename
+ </button>
+ <button class="secondary-chip !py-1 !px-3 !text-xs" @click="selectedNode = null">
+ <MaterialDesignIcon icon-name="close" class="w-3.5 h-3.5" />
+ </button>
+ </div>
+ </div>
+
+ <div
+ v-if="selectedNode.destination_hash"
+ class="p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
+ >
+ <div class="flex items-center justify-between mb-1">
+ <div class="text-xs font-bold uppercase tracking-wider">Destination Hash</div>
+ <button
+ v-if="selectedNode.running"
+ class="primary-chip !py-0.5 !px-2 !text-xs"
+ @click="viewNode(selectedNode)"
+ >
+ <MaterialDesignIcon icon-name="eye" class="w-3 h-3" />
+ View in Browser
+ </button>
+ </div>
+ <div class="font-mono text-sm select-all">{{ selectedNode.destination_hash }}</div>
+ </div>
+
+ <!-- Tabs: Pages / Files -->
+ <div class="flex gap-2 border-b border-gray-200 dark:border-zinc-700">
+ <button
+ :class="[
+ detailTab === 'pages'
+ ? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
+ : 'text-gray-600 dark:text-gray-400',
+ 'px-4 py-2 font-semibold transition text-sm',
+ ]"
+ @click="detailTab = 'pages'"
+ >
+ Pages ({{ selectedNode.pages.length }})
+ </button>
+ <button
+ :class="[
+ detailTab === 'files'
+ ? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
+ : 'text-gray-600 dark:text-gray-400',
+ 'px-4 py-2 font-semibold transition text-sm',
+ ]"
+ @click="detailTab = 'files'"
+ >
+ Files ({{ selectedNode.files.length }})
+ </button>
+ </div>
+
+ <!-- Pages Tab -->
+ <div v-if="detailTab === 'pages'" class="space-y-3">
+ <div class="flex gap-2">
+ <input
+ v-model="newPageName"
+ type="text"
+ placeholder="Page name (e.g. index)"
+ class="input-field flex-1"
+ @keyup.enter="addPage"
+ />
+ <button class="primary-chip !py-1 !px-3 !text-xs" @click="addPage">
+ <MaterialDesignIcon icon-name="plus" class="w-3.5 h-3.5" />
+ Add Page
+ </button>
+ </div>
+
+ <div
+ v-if="selectedNode.pages.length === 0"
+ class="text-sm text-gray-500 dark:text-gray-400 py-4 text-center"
+ >
+ No pages yet. Add one or publish from the Micron Editor.
+ </div>
+
+ <div
+ v-for="page in selectedNode.pages"
+ :key="page"
+ class="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-zinc-800/50 border border-gray-200 dark:border-zinc-700"
+ >
+ <div class="flex items-center gap-2">
+ <MaterialDesignIcon icon-name="file-document-outline" class="w-4 h-4 text-teal-500" />
+ <span class="text-sm font-mono text-gray-900 dark:text-white">{{ page }}</span>
+ </div>
+ <div class="flex items-center gap-2">
+ <button class="secondary-chip !py-0.5 !px-2 !text-xs" @click="editPage(page)">
+ Edit
+ </button>
+ <button
+ class="secondary-chip !py-0.5 !px-2 !text-xs !text-red-500"
+ @click="deletePage(page)"
+ >
+ <MaterialDesignIcon icon-name="delete" class="w-3 h-3" />
+ </button>
+ </div>
+ </div>
+
+ <!-- Page editor -->
+ <div v-if="editingPage" class="space-y-2">
+ <div class="flex items-center justify-between">
+ <div class="text-sm font-semibold text-gray-900 dark:text-white">
+ Editing: {{ editingPage }}
+ </div>
+ <div class="flex gap-2">
+ <button class="primary-chip !py-1 !px-3 !text-xs" @click="savePage">Save</button>
+ <button class="secondary-chip !py-1 !px-3 !text-xs" @click="editingPage = null">
+ Cancel
+ </button>
+ </div>
+ </div>
+ <textarea
+ v-model="editingPageContent"
+ class="w-full h-64 bg-white dark:bg-zinc-900 text-gray-900 dark:text-white p-3 font-mono text-sm rounded-lg border border-gray-200 dark:border-zinc-700 resize-y focus:outline-none focus:ring-2 focus:ring-blue-500/50"
+ ></textarea>
+ </div>
+ </div>
+
+ <!-- Files Tab -->
+ <div v-if="detailTab === 'files'" class="space-y-3">
+ <div class="flex gap-2">
+ <input ref="fileInput" type="file" class="hidden" @change="uploadFile" />
+ <button class="primary-chip !py-1 !px-3 !text-xs" @click="$refs.fileInput.click()">
+ <MaterialDesignIcon icon-name="upload" class="w-3.5 h-3.5" />
+ Upload File
+ </button>
+ </div>
+
+ <div
+ v-if="selectedNode.files.length === 0"
+ class="text-sm text-gray-500 dark:text-gray-400 py-4 text-center"
+ >
+ No files yet. Upload files to serve over the mesh.
+ </div>
+
+ <div
+ v-for="file in selectedNode.files"
+ :key="file.name"
+ class="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-zinc-800/50 border border-gray-200 dark:border-zinc-700"
+ >
+ <div class="flex items-center gap-2">
+ <MaterialDesignIcon icon-name="file-outline" class="w-4 h-4 text-blue-500" />
+ <span class="text-sm font-mono text-gray-900 dark:text-white">{{ file.name }}</span>
+ <span class="text-xs text-gray-500 dark:text-gray-400">{{
+ formatFileSize(file.size)
+ }}</span>
+ </div>
+ <button
+ class="secondary-chip !py-0.5 !px-2 !text-xs !text-red-500"
+ @click="deleteFile(file.name)"
+ >
+ <MaterialDesignIcon icon-name="delete" class="w-3 h-3" />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Create Node Dialog -->
+ <div
+ v-if="showCreateDialog"
+ class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
+ @click.self="showCreateDialog = false"
+ >
+ <div class="bg-white dark:bg-zinc-900 rounded-2xl p-6 w-full max-w-md mx-4 shadow-xl space-y-4">
+ <div class="text-lg font-semibold text-gray-900 dark:text-white">Create Mesh Server</div>
+ <div>
+ <label class="glass-label">Server Name</label>
+ <input
+ v-model="createNodeName"
+ type="text"
+ placeholder="My Mesh Server"
+ class="input-field"
+ @keyup.enter="createNode"
+ />
+ </div>
+ <div class="flex justify-end gap-2">
+ <button class="secondary-chip !py-1.5 !px-4 !text-sm" @click="showCreateDialog = false">
+ Cancel
+ </button>
+ <button class="primary-chip !py-1.5 !px-4 !text-sm" @click="createNode">Create</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Rename Dialog -->
+ <div
+ v-if="showRenameDialog"
+ class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
+ @click.self="showRenameDialog = false"
+ >
+ <div class="bg-white dark:bg-zinc-900 rounded-2xl p-6 w-full max-w-md mx-4 shadow-xl space-y-4">
+ <div class="text-lg font-semibold text-gray-900 dark:text-white">Rename Server</div>
+ <div>
+ <label class="glass-label">New Name</label>
+ <input
+ v-model="renameNodeName"
+ type="text"
+ :placeholder="selectedNode ? selectedNode.name : ''"
+ class="input-field"
+ @keyup.enter="renameNode"
+ />
+ </div>
+ <div class="flex justify-end gap-2">
+ <button class="secondary-chip !py-1.5 !px-4 !text-sm" @click="showRenameDialog = false">
+ Cancel
+ </button>
+ <button class="primary-chip !py-1.5 !px-4 !text-sm" @click="renameNode">Rename</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Status message -->
+ <div
+ v-if="statusMessage"
+ class="fixed bottom-4 right-4 z-50 p-3 rounded-lg shadow-lg text-sm"
+ :class="
+ statusSuccess
+ ? 'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-200'
+ : 'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-200'
+ "
+ >
+ {{ statusMessage }}
+ </div>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import DialogUtils from "../../js/DialogUtils";
+
+export default {
+ name: "PageNodesPage",
+ components: {
+ MaterialDesignIcon,
+ },
+ data() {
+ return {
+ nodes: [],
+ loading: true,
+ selectedNode: null,
+ detailTab: "pages",
+ showCreateDialog: false,
+ showRenameDialog: false,
+ createNodeName: "",
+ renameNodeName: "",
+ newPageName: "",
+ editingPage: null,
+ editingPageContent: "",
+ statusMessage: "",
+ statusSuccess: true,
+ statusTimeout: null,
+ };
+ },
+ async mounted() {
+ await this.loadNodes();
+ },
+ methods: {
+ async loadNodes() {
+ this.loading = true;
+ try {
+ const response = await window.axios.get("/api/v1/page-nodes");
+ this.nodes = response.data;
+ if (this.selectedNode) {
+ const updated = this.nodes.find((n) => n.node_id === this.selectedNode.node_id);
+ if (updated) {
+ this.selectedNode = updated;
+ } else {
+ this.selectedNode = null;
+ }
+ }
+ } catch (_) {
+ this.showStatus("Failed to load nodes", false);
+ } finally {
+ this.loading = false;
+ }
+ },
+ selectNode(node) {
+ this.selectedNode = node;
+ this.detailTab = "pages";
+ this.editingPage = null;
+ },
+ async createNode() {
+ if (!this.createNodeName.trim()) return;
+ try {
+ await window.axios.post("/api/v1/page-nodes", { name: this.createNodeName.trim() });
+ this.createNodeName = "";
+ this.showCreateDialog = false;
+ this.showStatus("Server created", true);
+ await this.loadNodes();
+ } catch (e) {
+ this.showStatus(e.response?.data?.message || "Failed to create server", false);
+ }
+ },
+ async deleteNode(nodeId) {
+ if (!(await DialogUtils.confirm("Delete this mesh server and all its content?"))) return;
+ try {
+ await window.axios.delete(`/api/v1/page-nodes/${nodeId}`);
+ if (this.selectedNode && this.selectedNode.node_id === nodeId) {
+ this.selectedNode = null;
+ }
+ this.showStatus("Server deleted", true);
+ await this.loadNodes();
+ } catch (_) {
+ this.showStatus("Failed to delete server", false);
+ }
+ },
+ async startNode(nodeId) {
+ try {
+ const response = await window.axios.post(`/api/v1/page-nodes/${nodeId}/start`);
+ this.showStatus(`Server started: ${response.data.destination_hash}`, true);
+ await this.loadNodes();
+ } catch (e) {
+ this.showStatus(e.response?.data?.message || "Failed to start server", false);
+ }
+ },
+ async stopNode(nodeId) {
+ try {
+ await window.axios.post(`/api/v1/page-nodes/${nodeId}/stop`);
+ this.showStatus("Server stopped", true);
+ await this.loadNodes();
+ } catch (_) {
+ this.showStatus("Failed to stop server", false);
+ }
+ },
+ async announceNode(nodeId) {
+ try {
+ await window.axios.post(`/api/v1/page-nodes/${nodeId}/announce`);
+ this.showStatus("Announced on mesh", true);
+ } catch (_) {
+ this.showStatus("Failed to announce", false);
+ }
+ },
+ async renameNode() {
+ if (!this.renameNodeName.trim() || !this.selectedNode) return;
+ try {
+ await window.axios.put(`/api/v1/page-nodes/${this.selectedNode.node_id}/rename`, {
+ name: this.renameNodeName.trim(),
+ });
+ this.renameNodeName = "";
+ this.showRenameDialog = false;
+ this.showStatus("Server renamed", true);
+ await this.loadNodes();
+ } catch (_) {
+ this.showStatus("Failed to rename server", false);
+ }
+ },
+ async addPage() {
+ if (!this.newPageName.trim() || !this.selectedNode) return;
+ try {
+ await window.axios.post(`/api/v1/page-nodes/${this.selectedNode.node_id}/pages`, {
+ name: this.newPageName.trim(),
+ content: "",
+ });
+ this.newPageName = "";
+ this.showStatus("Page created", true);
+ await this.loadNodes();
+ } catch (_) {
+ this.showStatus("Failed to create page", false);
+ }
+ },
+ async editPage(pageName) {
+ try {
+ const response = await window.axios.get(
+ `/api/v1/page-nodes/${this.selectedNode.node_id}/pages/${encodeURIComponent(pageName)}`
+ );
+ this.editingPage = pageName;
+ this.editingPageContent = response.data.content;
+ } catch (_) {
+ this.showStatus("Failed to load page", false);
+ }
+ },
+ async savePage() {
+ if (!this.editingPage || !this.selectedNode) return;
+ try {
+ await window.axios.post(`/api/v1/page-nodes/${this.selectedNode.node_id}/pages`, {
+ name: this.editingPage,
+ content: this.editingPageContent,
+ });
+ this.editingPage = null;
+ this.editingPageContent = "";
+ this.showStatus("Page saved", true);
+ await this.loadNodes();
+ } catch (_) {
+ this.showStatus("Failed to save page", false);
+ }
+ },
+ async deletePage(pageName) {
+ if (!(await DialogUtils.confirm(`Delete page "${pageName}"?`))) return;
+ try {
+ await window.axios.delete(
+ `/api/v1/page-nodes/${this.selectedNode.node_id}/pages/${encodeURIComponent(pageName)}`
+ );
+ if (this.editingPage === pageName) {
+ this.editingPage = null;
+ }
+ this.showStatus("Page deleted", true);
+ await this.loadNodes();
+ } catch (_) {
+ this.showStatus("Failed to delete page", false);
+ }
+ },
+ async uploadFile(event) {
+ const file = event.target.files[0];
+ if (!file || !this.selectedNode) return;
+ const formData = new FormData();
+ formData.append("file", file);
+ try {
+ await window.axios.post(`/api/v1/page-nodes/${this.selectedNode.node_id}/files`, formData, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
+ this.showStatus("File uploaded", true);
+ await this.loadNodes();
+ } catch (_) {
+ this.showStatus("Failed to upload file", false);
+ }
+ event.target.value = "";
+ },
+ async deleteFile(fileName) {
+ if (!(await DialogUtils.confirm(`Delete file "${fileName}"?`))) return;
+ try {
+ await window.axios.delete(
+ `/api/v1/page-nodes/${this.selectedNode.node_id}/files/${encodeURIComponent(fileName)}`
+ );
+ this.showStatus("File deleted", true);
+ await this.loadNodes();
+ } catch (_) {
+ this.showStatus("Failed to delete file", false);
+ }
+ },
+ viewNode(node) {
+ if (node.destination_hash) {
+ this.$router.push({
+ name: "nomadnetwork",
+ params: { destinationHash: node.destination_hash },
+ });
+ }
+ },
+ formatFileSize(bytes) {
+ if (bytes < 1024) return bytes + " B";
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
+ },
+ showStatus(message, success) {
+ this.statusMessage = message;
+ this.statusSuccess = success;
+ if (this.statusTimeout) clearTimeout(this.statusTimeout);
+ this.statusTimeout = setTimeout(() => {
+ this.statusMessage = "";
+ }, 3000);
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/tools/ToolsPage.vue b/meshchatx/src/frontend/components/tools/ToolsPage.vue
index 6f0bfe7c..3372ef60 100644
--- a/meshchatx/src/frontend/components/tools/ToolsPage.vue
+++ b/meshchatx/src/frontend/components/tools/ToolsPage.vue
@@ -231,12 +231,12 @@ export default {
},
},
{
- name: "rns-page-node",
- comingSoon: true,
+ name: "mesh-server",
+ route: { name: "mesh-server" },
icon: "server-network",
iconBg: "tool-card__icon bg-amber-50 text-amber-500 dark:bg-amber-900/30 dark:text-amber-200",
- titleKey: "tools.rns_page_node.title",
- descriptionKey: "tools.rns_page_node.description",
+ titleKey: "tools.mesh_server.title",
+ descriptionKey: "tools.mesh_server.description",
},
{
name: "rns-tunnel",
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 33682671..28654836 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1034,9 +1034,9 @@
"title": "Papiernachricht",
"description": "Erstellen und lesen Sie LXMF-signierte Papiernachrichten über QR-Codes."
},
- "rns_page_node": {
- "title": "RNS Page Node",
- "description": "Nomadnet-Micron-Seiten hosten und Dateien mühelos teilen."
+ "mesh_server": {
+ "title": "Mesh Server",
+ "description": "Micron-Seiten und Dateien direkt ueber das Reticulum-Mesh bereitstellen."
},
"rns_tunnel": {
"title": "RNSTunnel",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 7038f4bb..564ca921 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1085,9 +1085,9 @@
"title": "Paper Message",
"description": "Generate and read LXMF signed paper messages via QR codes."
},
- "rns_page_node": {
- "title": "RNS Page Node",
- "description": "Host Nomadnet micron pages and share files with ease."
+ "mesh_server": {
+ "title": "Mesh Server",
+ "description": "Serve Micron pages and files directly over the Reticulum mesh."
},
"rns_tunnel": {
"title": "RNS Tunnel",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index fe5b4289..c5fa00b6 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1123,9 +1123,9 @@
"title": "Messaggio Cartaceo",
"description": "Genera e leggi messaggi cartacei LXMF firmati tramite codici QR."
},
- "rns_page_node": {
- "title": "RNS Page Node",
- "description": "Ospita pagine micron Nomadnet e condividi file con facilità."
+ "mesh_server": {
+ "title": "Mesh Server",
+ "description": "Servi pagine Micron e file direttamente sulla rete Reticulum."
},
"rns_tunnel": {
"title": "RNS Tunnel",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index d9f8f5e6..a576b7ce 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1034,9 +1034,9 @@
"title": "Бумажное сообщение",
"description": "Создание и чтение подписанных бумажных сообщений LXMF через QR-коды."
},
- "rns_page_node": {
- "title": "RNS Page Node",
- "description": "Хостинг микрон-страниц Nomadnet и удобный обмен файлами."
+ "mesh_server": {
+ "title": "Mesh Server",
+ "description": "Раздавайте Micron-страницы и файлы напрямую через сеть Reticulum."
},
"rns_tunnel": {
"title": "RNS Tunnel",
diff --git a/meshchatx/src/version.py b/meshchatx/src/version.py
index a071f00f..7ec0b68e 100644
--- a/meshchatx/src/version.py
+++ b/meshchatx/src/version.py
@@ -2,4 +2,4 @@
Run: pnpm run version:sync
"""
-__version__ = "4.2.1"
+__version__ = "4.3.0"
diff --git a/tests/backend/test_app_status_tracking.py b/tests/backend/test_app_status_tracking.py
index d54a7ae4..1d20fbb3 100644
--- a/tests/backend/test_app_status_tracking.py
+++ b/tests/backend/test_app_status_tracking.py
@@ -94,8 +94,8 @@ async def test_app_status_endpoints(mock_rns_minimal, temp_dir):
app_instance.config.set("tutorial_seen", True)
assert app_instance.config.get("tutorial_seen") == "true"
- app_instance.config.set("changelog_seen_version", "4.2.1")
- assert app_instance.config.get("changelog_seen_version") == "4.2.1"
+ app_instance.config.set("changelog_seen_version", "4.3.0")
+ assert app_instance.config.get("changelog_seen_version") == "4.3.0"
# Test app_info returns these values
with ExitStack() as info_stack:
@@ -111,4 +111,4 @@ async def test_app_status_endpoints(mock_rns_minimal, temp_dir):
assert val == "true"
val = app_instance.config.get("changelog_seen_version")
- assert val == "4.2.1"
+ assert val == "4.3.0"
diff --git a/tests/backend/test_page_node.py b/tests/backend/test_page_node.py
new file mode 100644
index 00000000..17f080de
--- /dev/null
+++ b/tests/backend/test_page_node.py
@@ -0,0 +1,390 @@
+import os
+import shutil
+import tempfile
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+@pytest.fixture
+def node_dir():
+ d = tempfile.mkdtemp()
+ yield d
+ shutil.rmtree(d, ignore_errors=True)
+
+
+@pytest.fixture
+def mock_rns():
+ with patch("meshchatx.src.backend.page_node.RNS") as mock:
+ mock_identity = MagicMock()
+ mock_identity.hash = b"\x01" * 16
+ mock_identity.get_public_key.return_value = b"\x02" * 64
+
+ mock_destination = MagicMock()
+ mock_destination.hash = b"\x03" * 16
+
+ mock.Identity.return_value = mock_identity
+ mock.Identity.from_file.return_value = mock_identity
+ mock.Destination.return_value = mock_destination
+ mock.Destination.IN = 1
+ mock.Destination.SINGLE = 0
+ mock.Destination.ALLOW_ALL = 0xFF
+ mock.Transport = MagicMock()
+
+ yield mock, mock_identity, mock_destination
+
+
+def _make_node(node_dir, mock_rns):
+ from meshchatx.src.backend.page_node import PageNode
+
+ _, mock_identity, _ = mock_rns
+ return PageNode(
+ node_id="test-node-1",
+ name="Test Node",
+ base_dir=node_dir,
+ identity=mock_identity,
+ )
+
+
+class TestPageNodeSetup:
+ def test_setup_creates_directories(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ assert os.path.isdir(node.pages_dir)
+ assert os.path.isdir(node.files_dir)
+ assert node.running is True
+
+ def test_setup_returns_destination_hash_hex(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ result = node.setup()
+ assert isinstance(result, str)
+ assert len(result) == 32
+
+ def test_setup_creates_identity_when_none(self, node_dir, mock_rns):
+ from meshchatx.src.backend.page_node import PageNode
+
+ rns_mock, _, _ = mock_rns
+ node = PageNode(
+ node_id="test-node-2",
+ name="No Identity Node",
+ base_dir=node_dir,
+ )
+ node.setup()
+ rns_mock.Identity.assert_called()
+
+ def test_setup_loads_identity_from_file(self, node_dir, mock_rns):
+ from meshchatx.src.backend.page_node import PageNode
+
+ rns_mock, mock_identity, _ = mock_rns
+ identity_path = os.path.join(node_dir, "identity")
+ with open(identity_path, "w") as f:
+ f.write("fake")
+
+ node = PageNode(
+ node_id="test-node-3",
+ name="File Identity Node",
+ base_dir=node_dir,
+ )
+ node.setup()
+ rns_mock.Identity.from_file.assert_called_with(identity_path)
+
+ def test_setup_registers_link_callback(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ _, _, mock_dest = mock_rns
+ mock_dest.set_link_established_callback.assert_called_once()
+
+ def test_setup_calls_ensure_local_path(self, node_dir, mock_rns):
+ rns_mock, _, _ = mock_rns
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ rns_mock.Identity.remember.assert_called_once()
+
+
+class TestPageNodeTeardown:
+ def test_teardown_sets_running_false(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.teardown()
+ assert node.running is False
+ assert node.destination is None
+
+ def test_teardown_deregisters_handlers(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.add_page("test.mu", "content")
+ node.add_file("test.txt", b"data")
+ _, _, mock_dest = mock_rns
+ node.teardown()
+ assert mock_dest.deregister_request_handler.call_count >= 2
+
+ def test_teardown_clears_active_links(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ mock_link = MagicMock()
+ node.active_links.append(mock_link)
+ node.teardown()
+ assert len(node.active_links) == 0
+ mock_link.teardown.assert_called_once()
+
+
+class TestPageNodeAnnounce:
+ def test_announce_calls_destination_announce(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ _, _, mock_dest = mock_rns
+ node.announce()
+ mock_dest.announce.assert_called_once()
+
+ def test_announce_skips_when_not_running(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.announce()
+ _, _, mock_dest = mock_rns
+ mock_dest.announce.assert_not_called()
+
+ def test_announce_passes_name_as_app_data(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ _, _, mock_dest = mock_rns
+ node.announce()
+ call_kwargs = mock_dest.announce.call_args
+ assert call_kwargs[1]["app_data"] == b"Test Node"
+
+
+class TestPageNodePages:
+ def test_add_page_writes_file(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ name = node.add_page("hello", "Hello World")
+ assert name == "hello.mu"
+ path = os.path.join(node.pages_dir, "hello.mu")
+ assert os.path.isfile(path)
+ with open(path, "rb") as f:
+ assert f.read() == b"Hello World"
+
+ def test_add_page_appends_mu_extension(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ assert node.add_page("test", "x") == "test.mu"
+
+ def test_add_page_preserves_mu_extension(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ assert node.add_page("test.mu", "x") == "test.mu"
+
+ def test_add_page_registers_handler(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ _, _, mock_dest = mock_rns
+ node.add_page("index.mu", "content")
+ mock_dest.register_request_handler.assert_called()
+ assert "/page/index.mu" in node._registered_page_paths
+
+ def test_remove_page_deletes_file(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.add_page("deleteme.mu", "gone")
+ assert node.remove_page("deleteme.mu") is True
+ assert not os.path.isfile(os.path.join(node.pages_dir, "deleteme.mu"))
+
+ def test_remove_nonexistent_page_returns_false(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ assert node.remove_page("nope.mu") is False
+
+ def test_list_pages(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.add_page("b.mu", "b")
+ node.add_page("a.mu", "a")
+ pages = node.list_pages()
+ assert pages == ["a.mu", "b.mu"]
+
+ def test_get_page_content(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.add_page("index.mu", "Hello Mesh")
+ content = node.get_page_content("index.mu")
+ assert content == "Hello Mesh"
+
+ def test_get_page_content_nonexistent(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ assert node.get_page_content("nope.mu") is None
+
+
+class TestPageNodeFiles:
+ def test_add_file_writes_binary(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ name = node.add_file("doc.pdf", b"\x00\x01\x02")
+ assert name == "doc.pdf"
+ path = os.path.join(node.files_dir, "doc.pdf")
+ with open(path, "rb") as f:
+ assert f.read() == b"\x00\x01\x02"
+
+ def test_add_file_registers_handler(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.add_file("info.txt", b"data")
+ assert "/file/info.txt" in node._registered_file_paths
+
+ def test_remove_file(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.add_file("rm.bin", b"\xff")
+ assert node.remove_file("rm.bin") is True
+ assert not os.path.isfile(os.path.join(node.files_dir, "rm.bin"))
+
+ def test_remove_nonexistent_file(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ assert node.remove_file("nope.bin") is False
+
+ def test_list_files(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.add_file("b.txt", b"bb")
+ node.add_file("a.txt", b"a")
+ files = node.list_files()
+ assert len(files) == 2
+ assert files[0]["name"] == "a.txt"
+ assert files[1]["name"] == "b.txt"
+ assert files[0]["size"] == 1
+ assert files[1]["size"] == 2
+
+
+class TestPageNodeResponders:
+ def test_page_responder_returns_content(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.add_page("index.mu", "page content")
+ responder = node._make_page_responder("index.mu")
+ result = responder("/page/index.mu", None, "req1", "link1", None, 0)
+ assert result == b"page content"
+
+ def test_page_responder_missing_returns_none(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ responder = node._make_page_responder("missing.mu")
+ result = responder("/page/missing.mu", None, "req1", "link1", None, 0)
+ assert result is None
+
+ def test_file_responder_returns_list(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.add_file("data.bin", b"\x01\x02\x03")
+ responder = node._make_file_responder("data.bin")
+ result = responder("/file/data.bin", None, "req1", "link1", None, 0)
+ assert isinstance(result, list)
+ assert len(result) == 2
+ file_handle = result[0]
+ metadata = result[1]
+ assert file_handle.read() == b"\x01\x02\x03"
+ assert metadata["name"] == b"data.bin"
+ file_handle.close()
+
+ def test_file_responder_increments_stats(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.add_file("f.txt", b"hello")
+ responder = node._make_file_responder("f.txt")
+ assert node._stats["files_served"] == 0
+ responder("/file/f.txt", None, "r", "l", None, 0)
+ assert node._stats["files_served"] == 1
+ result = responder("/file/f.txt", None, "r", "l", None, 0)
+ result[0].close()
+ assert node._stats["files_served"] == 2
+
+
+class TestPageNodeConfig:
+ def test_save_and_load_config(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.save_config()
+ from meshchatx.src.backend.page_node import PageNode
+
+ config = PageNode.load_config(node_dir)
+ assert config is not None
+ assert config["node_id"] == "test-node-1"
+ assert config["name"] == "Test Node"
+
+ def test_load_config_missing(self, node_dir):
+ from meshchatx.src.backend.page_node import PageNode
+
+ assert PageNode.load_config(node_dir) is None
+
+
+class TestPageNodeStatus:
+ def test_get_status(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.add_page("index.mu", "hi")
+ status = node.get_status()
+ assert status["node_id"] == "test-node-1"
+ assert status["name"] == "Test Node"
+ assert status["running"] is True
+ assert status["destination_hash"] is not None
+ assert "index.mu" in status["pages"]
+ assert isinstance(status["stats"], dict)
+
+ def test_get_destination_hash_when_not_running(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ assert node.get_destination_hash() is None
+
+
+class TestPageNodeLinkCallbacks:
+ def test_link_established_callback(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ mock_link = MagicMock()
+ node._link_established(mock_link)
+ assert mock_link in node.active_links
+ assert node._stats["links_established"] == 1
+ mock_link.set_link_closed_callback.assert_called_once()
+
+ def test_link_closed_callback(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ mock_link = MagicMock()
+ node.active_links.append(mock_link)
+ node._link_closed(mock_link)
+ assert mock_link not in node.active_links
+
+ def test_link_closed_callback_ignores_unknown(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node._link_closed(MagicMock())
+ assert len(node.active_links) == 0
+
+
+class TestPageNodeEdgeCases:
+ def test_add_page_before_running(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ os.makedirs(node.pages_dir, exist_ok=True)
+ name = node.add_page("offline.mu", "data")
+ assert name == "offline.mu"
+ assert os.path.isfile(os.path.join(node.pages_dir, "offline.mu"))
+ assert "/page/offline.mu" not in node._registered_page_paths
+
+ def test_setup_registers_existing_pages(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ os.makedirs(node.pages_dir, exist_ok=True)
+ with open(os.path.join(node.pages_dir, "pre.mu"), "w") as f:
+ f.write("existing")
+ node.setup()
+ assert "/page/pre.mu" in node._registered_page_paths
+
+ def test_setup_registers_existing_files(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ os.makedirs(node.files_dir, exist_ok=True)
+ with open(os.path.join(node.files_dir, "pre.txt"), "wb") as f:
+ f.write(b"existing")
+ node.setup()
+ assert "/file/pre.txt" in node._registered_file_paths
+
+ def test_path_traversal_blocked(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ name = node.add_page("../../etc/passwd", "malicious")
+ assert name == "passwd.mu"
+ assert not os.path.exists(os.path.join(node_dir, "..", "etc"))
diff --git a/tests/backend/test_page_node_manager.py b/tests/backend/test_page_node_manager.py
new file mode 100644
index 00000000..18c0093e
--- /dev/null
+++ b/tests/backend/test_page_node_manager.py
@@ -0,0 +1,226 @@
+import os
+import shutil
+import tempfile
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+@pytest.fixture
+def storage_dir():
+ d = tempfile.mkdtemp()
+ yield d
+ shutil.rmtree(d, ignore_errors=True)
+
+
+@pytest.fixture
+def mock_rns():
+ with patch("meshchatx.src.backend.page_node.RNS") as mock:
+ mock_identity = MagicMock()
+ mock_identity.hash = b"\x01" * 16
+ mock_identity.get_public_key.return_value = b"\x02" * 64
+
+ mock_destination = MagicMock()
+ mock_destination.hash = b"\x03" * 16
+
+ mock.Identity.return_value = mock_identity
+ mock.Identity.from_file.return_value = mock_identity
+ mock.Destination.return_value = mock_destination
+ mock.Destination.IN = 1
+ mock.Destination.SINGLE = 0
+ mock.Destination.ALLOW_ALL = 0xFF
+ mock.Transport = MagicMock()
+
+ yield mock, mock_identity, mock_destination
+
+
+def _make_manager(storage_dir):
+ from meshchatx.src.backend.page_node_manager import PageNodeManager
+
+ return PageNodeManager(storage_dir)
+
+
+class TestPageNodeManagerCreate:
+ def test_create_node(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("My Server")
+ assert node.name == "My Server"
+ assert node.node_id in mgr.nodes
+ assert os.path.isdir(node.base_dir)
+
+ def test_create_node_with_custom_id(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("Custom", node_id="custom-123")
+ assert node.node_id == "custom-123"
+
+ def test_create_node_saves_config(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("Saved")
+ config_path = os.path.join(node.base_dir, "config.json")
+ assert os.path.isfile(config_path)
+
+
+class TestPageNodeManagerDelete:
+ def test_delete_node(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("ToDelete")
+ node_id = node.node_id
+ base_dir = node.base_dir
+ assert mgr.delete_node(node_id) is True
+ assert node_id not in mgr.nodes
+ assert not os.path.exists(base_dir)
+
+ def test_delete_running_node_stops_first(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("Running")
+ mgr.start_node(node.node_id)
+ assert node.running is True
+ mgr.delete_node(node.node_id)
+ assert node.running is False
+
+ def test_delete_nonexistent_returns_false(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ assert mgr.delete_node("nonexistent") is False
+
+
+class TestPageNodeManagerStartStop:
+ def test_start_node(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("StartMe")
+ dest_hash = mgr.start_node(node.node_id)
+ assert node.running is True
+ assert dest_hash is not None
+
+ def test_start_already_running(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("AlreadyRunning")
+ mgr.start_node(node.node_id)
+ result = mgr.start_node(node.node_id)
+ assert result is not None
+
+ def test_start_nonexistent_raises(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ with pytest.raises(KeyError):
+ mgr.start_node("nope")
+
+ def test_stop_node(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("StopMe")
+ mgr.start_node(node.node_id)
+ mgr.stop_node(node.node_id)
+ assert node.running is False
+
+ def test_stop_nonexistent_raises(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ with pytest.raises(KeyError):
+ mgr.stop_node("nope")
+
+ def test_start_all(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ mgr.create_node("A", node_id="a")
+ mgr.create_node("B", node_id="b")
+ mgr.start_all()
+ for node in mgr.nodes.values():
+ assert node.running is True
+
+ def test_stop_all(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ mgr.create_node("A", node_id="a")
+ mgr.create_node("B", node_id="b")
+ mgr.start_all()
+ mgr.stop_all()
+ for node in mgr.nodes.values():
+ assert node.running is False
+
+
+class TestPageNodeManagerAnnounce:
+ def test_announce_node(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("Announcer")
+ mgr.start_node(node.node_id)
+ _, _, mock_dest = mock_rns
+ mgr.announce_node(node.node_id)
+ mock_dest.announce.assert_called()
+
+ def test_announce_nonexistent_raises(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ with pytest.raises(KeyError):
+ mgr.announce_node("nope")
+
+ def test_announce_all(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ mgr.create_node("A", node_id="a")
+ mgr.create_node("B", node_id="b")
+ mgr.start_all()
+ _, _, mock_dest = mock_rns
+ mgr.announce_all()
+ assert mock_dest.announce.call_count >= 2
+
+
+class TestPageNodeManagerRename:
+ def test_rename_node(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("OldName")
+ mgr.rename_node(node.node_id, "NewName")
+ assert node.name == "NewName"
+
+ def test_rename_nonexistent_raises(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ with pytest.raises(KeyError):
+ mgr.rename_node("nope", "Ignored")
+
+
+class TestPageNodeManagerGetAndList:
+ def test_get_node(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("FindMe")
+ assert mgr.get_node(node.node_id) is node
+
+ def test_get_node_missing(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ assert mgr.get_node("nope") is None
+
+ def test_list_nodes(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ mgr.create_node("A", node_id="a")
+ mgr.create_node("B", node_id="b")
+ result = mgr.list_nodes()
+ assert len(result) == 2
+ ids = {n["node_id"] for n in result}
+ assert ids == {"a", "b"}
+
+
+class TestPageNodeManagerLoad:
+ def test_load_nodes_from_disk(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("Persisted", node_id="persist-1")
+ node.save_config()
+
+ mgr2 = _make_manager(storage_dir)
+ mgr2.load_nodes()
+ assert "persist-1" in mgr2.nodes
+ assert mgr2.nodes["persist-1"].name == "Persisted"
+
+ def test_load_nodes_skips_bad_dirs(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ bad_dir = os.path.join(mgr.storage_dir, "bad-node")
+ os.makedirs(bad_dir, exist_ok=True)
+ mgr.load_nodes()
+ assert "bad-node" not in mgr.nodes
+
+ def test_load_nodes_skips_already_loaded(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ node = mgr.create_node("Already", node_id="already-1")
+ node.save_config()
+ mgr.load_nodes()
+ assert len(mgr.nodes) == 1
+
+
+class TestPageNodeManagerTeardown:
+ def test_teardown_stops_and_clears(self, storage_dir, mock_rns):
+ mgr = _make_manager(storage_dir)
+ mgr.create_node("A", node_id="a")
+ mgr.create_node("B", node_id="b")
+ mgr.start_all()
+ mgr.teardown()
+ assert len(mgr.nodes) == 0
diff --git a/tests/frontend/AppModals.test.js b/tests/frontend/AppModals.test.js
index b649d75c..74236999 100644
--- a/tests/frontend/AppModals.test.js
+++ b/tests/frontend/AppModals.test.js
@@ -62,9 +62,9 @@ describe("App.vue Modals", () => {
return Promise.resolve({
data: {
app_info: {
- version: "4.2.1",
+ version: "4.3.0",
tutorial_seen: true,
- changelog_seen_version: "4.2.1",
+ changelog_seen_version: "4.3.0",
},
},
});
@@ -94,7 +94,7 @@ describe("App.vue Modals", () => {
return Promise.resolve({
data: {
app_info: {
- version: "4.2.1",
+ version: "4.3.0",
tutorial_seen: false,
changelog_seen_version: "0.0.0",
},
@@ -153,7 +153,7 @@ describe("App.vue Modals", () => {
return Promise.resolve({
data: {
app_info: {
- version: "4.2.1",
+ version: "4.3.0",
tutorial_seen: true,
changelog_seen_version: "3.9.0",
},
@@ -161,7 +161,7 @@ describe("App.vue Modals", () => {
});
}
if (url === "/api/v1/app/changelog") {
- return Promise.resolve({ data: { html: "<h1>New Features</h1>", version: "4.2.1" } });
+ return Promise.resolve({ data: { html: "<h1>New Features</h1>", version: "4.3.0" } });
}
if (url === "/api/v1/config") return Promise.resolve({ data: { config: { theme: "dark" } } });
if (url === "/api/v1/auth/status") return Promise.resolve({ data: { auth_enabled: false } });
diff --git a/tests/frontend/ChangelogModal.test.js b/tests/frontend/ChangelogModal.test.js
index 194e2551..19524ef6 100644
--- a/tests/frontend/ChangelogModal.test.js
+++ b/tests/frontend/ChangelogModal.test.js
@@ -78,7 +78,7 @@ describe("ChangelogModal.vue", () => {
axiosMock.get.mockResolvedValue({
data: {
html: "<h1>Test</h1>",
- version: "4.2.1",
+ version: "4.3.0",
},
});
@@ -95,7 +95,7 @@ describe("ChangelogModal.vue", () => {
axiosMock.get.mockResolvedValue({
data: {
html: "<h1>Test</h1>",
- version: "4.2.1",
+ version: "4.3.0",
},
});
@@ -114,7 +114,7 @@ describe("ChangelogModal.vue", () => {
axiosMock.get.mockResolvedValue({
data: {
html: "<h1>Test</h1>",
- version: "4.2.1",
+ version: "4.3.0",
},
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────